Cloud-Applikationen im Laufstall
Dass große Freiheit mit großer Verantwortung einhergeht, ist einer ältesten Scherze im Bereich des IT-Managements. Für Cloud Services gilt (logischerweise) Ähnliches: Desto weniger Optionen Entwickler:innen zur Verfügung stehen, desto wahrscheinlicher ist es, dass sie sich an vernünftigen Design Patterns orientieren.
ZUM NEWSLETTER
Regelmäßig News zur Konferenz und der .NET-Community
Mit .NET Aspire schlägt Microsoft den angesprochenen Weg ein – schon in der Ankündigung findet sich das folgende Zitat, der Begriff „opinionated“ lässt sich in diesem Zusammenhang als „streng implementiert“ übersetzen: „.NET Aspire is an opinionated stack for building resilient, observable, and configurable cloud-native applications with .NET. It includes a curated set of components enhanced for cloud-native by including service discovery, telemetry, resilience, and health checks by default.“ [1]
Als Anbieter von Cloud-Lösungen ist Microsoft naturgemäß in einer einzigartigen Position, um der Nutzerschaft Vorgaben zu machen. Wenn man in der Lage ist, die Aufmerksamkeit der Entwickler:innen auf bestimmte Dienste zu richten, so lassen sich Kosten, beispielsweise für die Provisionierung, einsparen. In diesem Artikel werfen wir einen ersten Blick darauf, wie .NET Aspire funktioniert und welche praktischen Überlegungen bei der Nutzung zu beachten sind.
Inbetriebnahme der Arbeitsumgebung
Aus dem opinionated Aufbau des Entwicklungssystems folgt, dass Microsoft auch den Entwickler:innen bzw. ihren Workstationkonfigurationen enge Daumenschrauben angelegt. Wer mit Visual Studio arbeiten möchte, benötigt die brandaktuelle Preview-Version 17.9 – der Autor wird die Variante 17.9.0 Preview 1.1 verwenden und eine Windows-11-Workstation als Host einspannen. Im Visual Studio Installer findet sich dann in der Rubrik ASP.NET und Webentwicklung die Option für das Herunterladen der Payload .NET Aspire SDK (Preview) (Abb. 1).
Abb. 1: Aspire-Entwicklung setzt eine spezielle Payload voraus
Zu beachten ist außerdem, dass die Arbeit mit Aspire prinzipiell und immer die Verwendung von .NET in Version 8.0 voraussetzt. Im Hintergrund erfolgt der Großteil der Ausführung unter Nutzung von Docker-Containern. Aus diesem Grund setzt die Aspire-Payload auch das Vorhandensein von Docker Desktop voraus. Ein Produkt, dass der Visual Studio Installer schon aus lizenzrechtlichen Gründen nicht automatisch herunterladen darf.
Zur Behebung des Problems besuchen wir im ersten Schritt [2] und klicken dort auf den Knopf Download for Windows. Nach dem Herunterladen der rund 600 MB großen .msi-Datei installieren wir diese im Allgemeinen wie gewohnt. Wer Docker Desktop – wie der Autor – für die folgenden Experimente frisch installiert, muss darauf achten, dass der Startbildschirm des Produkts den erfolgreichen Start des Docker-Containers ankündigt.
Als Nächstes erfolgt der Wechsel in Visual Studio 2022, wo ein neues Projekt auf Basis der Vorlage .NET Aspire Starter Application entsteht. Die in Visual Studio implementierte Search Engine zeigt sich dabei übrigens widerspenstig. Suchen Sie bitte nach dem String „Aspire Starter“, um die Einschränkungen zu aktivieren. In der Vorlage implementiert Microsoft – auf Wunsch – dabei verschiedene vorgefertigte Elemente. Wichtig ist, dass sie im Drop-down-Menü Framework wie in Abbildung 2 gezeigt .NET 8.0 auswählen und die Checkbox für Redis aktivieren.
Abb. 2: Diese Checkbox spart Zeit und Kosten
Lohn der Mühen ist die Erzeugung des in Abbildung 3 gezeigten Projektskeletts, dass – man denke abermals an das Stichwort des opinionated Designs – aus einer ganzen Gruppe von Unterprojekten aufgebaut ist.
Abb. 3: Aspire Solutions sind durchaus kompliziert
Im Interesse der besseren Verständlichkeit ist es empfehlenswert, das Projekt im ersten Schritt unter Nutzung der Visual-Studio-Default-Payload HTTP zur Ausführung zu bringen. Hierdurch erscheinen die in Abbildung 4 gezeigten Fenster. Ein Blick auf die Docker-Desktop-Umgebung zeigt, dass Visual Studio auch einen Container für uns aktiviert hat (Abb. 5).
Abb. 4: Wer eine Aspire-Applikation startet, bekommt mehrere Fenster
Abb. 5: Dieser Container gehört zu uns
Nun wollen wir aber zum Browserfenster zurückkehren: Aspire-basierte Solutions werden von Microsoft prinzipiell mit einem Dashboard ausgestattet, das Konfiguration und Überwachung der verschiedenen Elemente der Solution erleichtert. Sinn dieser Vorgehensweise ist, dass technisch herausgeforderte Entwickler nicht mit der (oft komplizierten) Logik zum Monitoring der verschiedenen Cloud-Projekte kämpfen müssen.
Interessant ist für uns außerdem die Rubrik Projects, in der unter der Rubrik Endpoints zwei unterschiedliche Solutions zur Verfügung stehen. Unter Apiservice findet sich dabei ein Dienst, der JSON-Daten mit Dummy-Wetterdaten anzeigt. Das Webfrontend-Paket realisiert stattdessen eine Blazor-App. Zu beachten ist, dass das Anklicken der Endpoint URLs in der Rubrik Projects die jeweiligen Elemente direkt im Edge-Browser öffnet.
Analyse der Projektstruktur
Zum Zeitpunkt der Drucklegung dieses Artikels liegt Aspire noch in einem sehr frühen Zustand vor: Das äußert sich unter anderem darin, dass viele der Funktionen noch nicht im Benutzerinterface von Visual Studio angelegt sind. Als Erstes wollen wir uns das Projekt AspireApp1.AppHost ansehen, das als Dreh und Angelpunkt für die Aspire-Lösung dient und auch für die eigentliche Ausführung des Projekts verantwortlich ist. Am wichtigsten ist die Datei AspireApp1.AppHost.csproj, in der wir nach einer Analyse im Codeeditor das folgende zur Aspire-Umgebung gehörende Attribut vorfinden:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> . . . <IsAspireHost>true</IsAspireHost>
In der Datei Program.cs findet sich dann die eigentliche Initialisierung, die unser Wetterdatenanzeigesystem ins Leben ruft. Microsoft orientiert sich dabei übrigens an den aus .NET Generic Host und ASP.NET Core Web Host bekannten Design Patterns, weshalb mit diesen Technologien erfahrene Entwickler:innen einen schnelleren Umstieg erleben:
var builder = DistributedApplication.CreateBuilder(args); var cache = builder.AddRedisContainer("cache");
Nach der Erzeugung des DistributedApplication-Objekts fügen wir diesen ersten Schritt einen Redis-Container hinzu: Aus der Methode AddRedisContainer lässt sich ableiten, dass es sich dabei um ein vorgefertigtes Modul handelt. Im nächsten Schritt folgt das Hinzufügen der zwei in unserer Solution manuell angelegten Projekte (Listing 1):
Listing 1 var apiservice = builder.AddProject<Projects.AspireApp1_ApiService>("apiservice"); builder.AddProject<Projects.AspireApp1_Web>("webfrontend") .WithReference(cache) .WithReference(apiservice); builder.Build().Run();
Zum besseren Verständnis wollen wir an dieser Stelle auf die von Microsoft bereitgestellte und in Abbildung 6 gezeigte Grafik zurückgreifen, die die in unserem Aspire-Projekt vorhandenen Komponenten visualisiert.
Aspire-Applikationen bestehen intern aus verschiedenen Instanzen von Resourcen: Zum Zeitpunkt der Drucklegung sind drei bekannt. Als Erstes die durch AddProject hinzuzufügende ProjectResource, die ein .NET-Projekt kennzeichnet. Die ContainerResource – das Einpflegen erfolgt durch AddContainer – kümmert sich um Container-Images, während die durch AddExecutable einzufügende ExecutableResource-Klasse mehr oder weniger beliebige .exe-Dateien in den Aspire-Verbund einbindet. Wichtig ist außerdem noch, dass jede im Verbund lebende Ressource prinzipiell und immer einen einzigartigen Namen aufweisen muss.
Zentralisierte Orchestrierung und Konfiguration
Ein von Quereinsteiger:innen im Bereich der Elektronik häufig unterschätztes Problem ist, das Hochfahren der einzelnen Spannungsversorgungen in einem System zu verwalten. Benötigt ein System mehrere Spannungen und tauchen diese in der falschen Reihenfolge auf, so kommt es mitunter zu seltsamen Problemen.
Im Fall von Cloud-basierten bzw. verteilten Systemen gibt es ähnliche Probleme: Steht ein Konfigurations-Microservice nicht zur Verfügung, wenn ein anderer Dienst hochfährt, entsteht Durcheinander. Damit ist auch schon einer der wichtigsten Problemfälle beschrieben, denen Microsoft mit .NET Aspire entgegenwirken möchte.
Zum Verständnis des Orchestrierungsprozesses wollen wir uns die Datei Program.cs ansehen, die im Projekt AspireApp1.ApiService unterkommt und für die Bereitstellung des API-Endpunkts unseres Wettervorhersagesystems verantwortlich zeichnet. Die Initialisierung erfolgt über einen AppBuilder:
var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); builder.Services.AddProblemDetails(); var app = builder.Build(); app.UseExceptionHandler();
Interessant ist hier der Aufruf der Methode AddServiceDefaults(), die Aspire zur Nutzung der projektglobalen Standardeinstellungen für verschiedene häufig benötigte Elemente der Solution animiert. Die restliche Initialisierung kümmert sich vor allem um das Einrichten eines Event Handler, der die ausgegebenen Informationen per Zufallsgenerator generiert. Um es kompakt zu halten, drucken wir diesen Teil des Codes gekürzt ab (Listing 3). Wichtig ist noch der Aufruf von Run, der die Aspire-Arbeitsumgebung zur eigentlichen Ausführung des Codes animiert. Angemerkt sei, dass Microsoft in .NET Aspire auch verschiedene andere Methoden zur Konfiguration anbietet – unter [4] findet sich eine Übersicht.
Listing 3 app.MapGet("/weatherforecast", () => { var forecast = Enumerable.Range(1, 5).Select(index => . . . return forecast; }); app.MapDefaultEndpoints(); app.Run();
Interessanter ist für uns an dieser Stelle die Frage, woher der Aspire-Orchestrator die anzuwendenden Einstellungen bezieht und welche Teile der Solutions er zu konfigurieren sucht. Die Antwort auf diese Frage findet sich im Projekt AspireApp1.ServiceDefaults. Ich möchte mich auch an dieser Stelle kurz fassen; unter [5] findet sich eine vollständige Analyse des vergleichsweise reich kommentierten Codes.
Im Prinzip erfolgt die Initialisierung durch statische Methoden. Am wichtigsten ist die Funktion AddServiceDefaults, die, wie hier gekürzt gezeigt, verschiedene Basisdienste wie den Orchestrierungsservice konfiguriert und zur Solution hinzufügt:
public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) { builder.ConfigureOpenTelemetry(); builder.AddDefaultHealthChecks(); builder.Services.AddServiceDiscovery(); . . .
Im Bereich der Telemetriedatenerfassung setzt .NET Aspire auf OpenTelemetry – weitere Informationen hierzu finden sich unter [6]. Wichtig ist, dass die in .NET Aspire enthaltene Implementierung ebenfalls konfiguriert werden muss. Das ist die Aufgabe der Methode ConfigureOpenTelemetry, die wir hier aus Platzgründen nicht abdrucken.
Zu guter Letzt implementiert Aspire auch noch Health Checks. Dabei handelt es sich um Methoden, die das Überprüfen der Systemgesundheit des von .NET Aspire verwalteten Dienst ermöglichen. Ihre Einrichtung erfolgt in den Methoden AddDefaultHealthChecks und MapDefaultEndpoints.
Aspire-Komponenten erlauben das einfache Einpflegen häufig benötigter Dienste. Neben dem Orchestrierungs-Service ist vor allem die Komponentenfunktionalität von .NET Aspire relevant. Dabei handelt es sich um ein Feature, mit dem Microsoft die direkte Integration häufig benötigter Komponenten in die Solution ermöglicht. Zum Zeitpunkt der Drucklegung dieses Artikels bietet die unter [7] ansprechbare Liste dabei die in Abbildung 7 gezeigten Komponenten an.
Abb. 7: In Microsofts Komponentenzoo wimmelt es
Zu Demonstrationszwecken werden wir in den folgenden Schritten einen Azure-Dienst hinzufügen. Im ersten Schritt klicken wir die Projektmappe rechts an, und entscheiden uns für den Assistenten zum Hinzufügen eines neuen Projekts.
Suchen Sie nach der Vorlage Workerdienst und entscheiden Sie sich für die Variante für C#. Relevant ist, dass diese Vorlage nicht Teil der von .NET Aspire eingefügten Elemente ist. Als Name wird der Autor in den folgenden Schritten AspireApp1.AzWorker vergeben. Interessant ist außerdem, dass Sie im Projektkonfigurationsfenster, wie in Abbildung 8 gezeigt, die Option haben, die Checkbox In Aspire-Orchestrierung eintragen zu aktivieren. Diese sollten Sie auswählen, weil sich Visual Studio dann darum kümmert, unsere Worker-Dienste teilweise in die Solutions einzupflegen.
Abb. 8: Die Checkbox erleichtert das Ins-Leben-Rufen neuer Services
Dank der markierten Checkbox gibt es in Program.cs eine nach folgendem Schema erfolgende Anpassung, die sich um das Einpflegen der neuen Komponente kümmert:
. . . builder.AddProject<Projects.AspireApp1_AzWorker>("aspireapp1.azworker"); builder.Build().Run();
Im nächsten Schritt klicken wir das neu erzeugte Paket rechts an, um die NuGet-Paketverwaltung zu öffnen. Ebenda entscheiden wir uns für das Paket Aspire.Azure.Storage.Blobs, das zum Zeitpunkt der Drucklegung dieses Artikels als Version 8.0.0-preview.1.23557.2 vorliegt. Danach verfahren wir analog mit dem Paket Aspire.Azure.Storage.Queues.
Angemerkt sei hier, dass .NET Aspire als .NET-Technologie logischerweise nicht von den Zwängen befreit ist, die im Bereich der .NET-Technologien zum Tragen kommen. Wichtig ist, dass das Hinzufügen der NuGet-Pakete in allen Solutions gleichermaßen erforderlich ist, die später mit dem jeweiligen Feature des verteilten Systems interagieren wollen oder sollen.
Im nächsten Schritt ist ein Wechsel in das Azure-Backend erforderlich, wo im ersten Schritt ein neuer Storage-Account entsteht. Der Autor vergab in den folgenden Schritten den Namen susaspireexperiment und entschied sich für die preiswerteste Redundanzklasse und eine geringe Performance-Tier. Für unsere hier durchgeführten Experimente ist ein Mehr an Systemperformance nicht notwendig, würde die Kosten aber immens erhöhen.
Im nächsten Schritt benötigen wir einen Blob-Storage-Container und eine Storage-Queue-Instanz: Wichtig ist in beiden Fällen, die Connection-Strings zur Verfügung zu haben. Wichtig ist außerdem, den Connection-String auf Ebene des Storage-Accounts zu beziehen – Abbildung 9 zeigt den korrekten Platz.
Abb. 9: Der Connection-String ist zum Abernten bereit
Als Nächstes stellt sich die Frage, wie der derzeit als String vorliegenden Connection-String in die .NET Aspire Solution einbezogen werden kann. Die Antwort darauf ist die Datei appsettings.json – beachten Sie, dass diese Adaption für jedes mit den Azure-Ressourcen zu verbindende Projekt von Hand durchgeführt werden muss (Listing 4).
Listing 4 { "Logging": { . . . }, "ConnectionStrings": { "blobdb": "DefaultEndpointsProtocol=https;AccountName=susaspire. . .", "queuedb": "DefaultEndpointsProtocol=https;AccountName=susaspire. . ." } }
Im vorliegenden Fall ist derselbe Schlüssel unter zwei unterschiedlichen Namen angelegt: Ursache dafür ist die weiter oben erwähnte Bedingung, dass Namen nur einmal im Verbund vorkommen dürfen. Für die erfolgreiche Initialisierung der Containerinfrastruktur ist im AppHost außerdem das Hinzufügen des NuGet-Pakets Aspire.Hosting.Azure erforderlich.
SIE LIEBEN .NET?
Entdecken Sie die BASTA! Tracks
Nach der Erzeugung der Service-Primitiva müssen diese im ersten Schritt zum Webprojekt hinzugefügt werden. Hierzu sind einfach weitere Aufrufe der Methode Add* notwendig, die die schon vorhandenen Aufrufe um die weiteren benötigten Komponenten ergänzen:
using Microsoft.Extensions.Hosting; var builder = DistributedApplication.CreateBuilder(args); var storage = builder.AddAzureStorage("tamsstore"); var blobs = storage.AddBlobs("blobdb"); var queues = storage.AddQueues("queuedb");
Im Fall des neuen von Visual Studio hinzugefügten Workers stehen noch keine Referenzen zur Verfügung, weshalb hier etwas umfangreichere Anpassungen der Deklaration anfallen:
builder.AddProject<Projects.AspireApp1_AzWorker>("aspireapp1.azworker") .WithReference(blobs) .WithReference(queues);
Im Webprojekt muss im nächsten Schritt die Deklaration aus Listing 5 eingefügt werden.
Listing 5 builder.AddProject<Projects.AspireApp1_Web>("webfrontend") .WithReference(blobs) .WithReference(queues) .WithReference(cache) .WithReference(apiservice); var builder = WebApplication.CreateBuilder(args); builder.AddAzureBlobService("blobdb"); builder.AddAzureQueueService("queuedb"); builder.AddServiceDefaults();
Danach wechseln wir in die Webapplikation, in der einige benötigte Dependencies importiert werden, wie in Listing 6 zu sehen ist.
Listing 6 @page "/counter" @rendermode InteractiveServer @using System.ComponentModel.DataAnnotations @using Azure.Storage.Blobs @using Azure.Storage.Queues @inject BlobServiceClient BlobClient @inject QueueServiceClient QueueServiceClient
Der Container erschwert Aspire-Entwicklern an dieser Stelle das Leben insofern, als er die in die Blobs zu schreibenden Informationen ausschließlich in Form von Streams erwartet. Wer wie wir hier einen String hochladen möchte, muss diesen zunächst vergleichsweise aufwendig in eine Stream-Instanz umwandeln (Listing 7).
Listing 7 @code { private int currentCount = 0; private async void IncrementCount() { String usefulData ="ThisIsTheTestData"; var stream = new MemoryStream(); var writer = new StreamWriter(stream); writer.Write(usefulData); writer.Flush(); stream.Position = 0; var docsContainer = BlobClient.GetBlobContainerClient("fileuploads"); await docsContainer.UploadBlobAsync( "TestFile",stream); currentCount++; } }
Wer den Knopf dann anklickt, sieht das Aufscheinen eines Fehler-Callouts (Abb. 10) in der von .NET Aspire ebenfalls zur Verfügung gestellten Arbeitsoberfläche. Das Anklicken dieser Option führt zum Aufscheinen eines zusätzlichen Fensters, das – ganz analog zum klassischen Visual Studio – sogar Details zum Aufrufstack und zu den im Exception-Objekt angelegten Fehlerinformationen anbietet.
Abb. 10: Während der Ausführung von Aspire-Applikation auftretende Fehler werden im Web-Frontend zentralisiert präsentiert
Eine detaillierte Analyse des zurückgelieferten Fehlerstrings führt uns dann auch schon zum Ziel: Der Autor hatte einen Tippfehler: Der im Azure-Cloud-Backend angelegte Name des Blobs stimmte nicht mit dem überein, den die Methode erwartet hatte. Mit diesem Wissen lässt sich der Code wie folgt verbessern:
var docsContainer = BlobClient.GetBlobContainerClient("susaspirecont");
Bei der Programmausführung zeigt nun der Blob im Backend die per Aspire angelieferten Informationen an.
Fazit
Mit .NET Aspire integriert Microsoft Lifecycle-Management in die .NET-Arbeitsumgebung: eine Funktion, die bisher beispielsweise in Docker Compose zur Verfügung gestellt wurde. Schon durch die Möglichkeit, mehr Services aus einer Hand zu beziehen, geht das mit einer Erhöhung der Lebensqualität für Entwickler:innen einher. Fraglich ist allerdings, ob bzw. inwiefern diese systemimmanenten Vorteile ausreichen, um Microsoft das Angreifen der gut etablierten Konkurrenz im Umfeld der Containerorchestrierung zu ermöglichen.
Links & Literatur
[1] https://devblogs.microsoft.com/dotnet/introducing-dotnet-aspire-simplifying-cloud-native-development-with-dotnet-8/
[2] https://www.docker.com/products/docker-desktop/
[3] https://learn.microsoft.com/en-us/dotnet/aspire/app-host-overview
[4] https://learn.microsoft.com/en-us/dotnet/aspire/service-defaults
[5] https://learn.microsoft.com/en-us/dotnet/aspire/service-discovery/overview
[6] https://opentelemetry.io
[7] https://learn.microsoft.com/en-us/dotnet/aspire/components-overview?tabs=dotnet-cli